BlogGuestBook
인스타그램 주소
HJ DevLog
©2025 효중킴의 블로그, Powered By Next.js

TQ로 SSR처리하기

SSR을 TanstackQuery로 처리하기

2024-07-04

공식문서에서, 서버에서 데이터를 프리패칭하고 queryClient에 제공하는 방법은 2가지 가 존재한다.

  • initalData를 사용하는 방법
  • Hydration을 사용하는 방법

먼저 initalData를 사용하는 방법에 대해 알아보자. 쿼리에 initalData를 주입하는 방 법은 query옵션에 직접 주입하는 방법과, queryClient의 prefetchQuery,setQueryData 메서드를 사용하는 방법이 있다.

initalData_placeholderData

config.initalData옵션으로 쿼리의 초기 데이터를 설정할 수 있다. 다만 initalData는 캐시에 남는다. 캐시 레벨에서 동작하고, 오직 하나만 존재할 수 있다. 만약 placeholder를 제공하고 싶다면, placeholderData를 사용한다.

function Component() {
  //둘다 status는 success가 되어 있음.
  const { data, status } = useQuery({
    queryKey: ['number'],
    queryFn: fetchNumber,
    placeholderData: 23,
  });

  const { data, status } = useQuery({
    queryKey: ['number'],
    queryFn: fetchNumber,
    initalData: () => 42,
  });
}

cacheLevel_ObserverLevel

둘의 가장 큰 차이는 캐시에 들어가냐? 아니냐? 의 차이이다. initalData의 경우에는 캐시 객체를 만들 때 직접 옵션으로 넣어둔 것을 사용하는 것을 볼 수 있다.

//export class Query...

this.#initialState = config.state || getDefaultState(this.options);

이 캐시 객체는 각 쿼리 키에 대해 존재한다.

이 쿼리 키로 애플리케이션에서 전역적으로 동일한 데이터를 관리하게 도와주고, 쿼리 키당 하나의 엔트리만 존재하기 떄문에, staleTime, cacheTime에 따라 stale, GC에 의 해 수거되는 시간을 알 수 있다.

반면 Observer level같은 경우 하나의 쿼리 레벨에 생성된 구독을 의미한다. 캐시 레 벨단에서의 변경 사항을 확인하고, 변경사항이 있다면 알림을 받는다 (이 역할을 notifymanager가 한다.!)

이 observer는 useQuery훅을 호출할 떄마다 생긴다. 즉 동일한 캐시를 바라보는 여러 옵저버가 존재할 수 있다.

initalData는 cache Level에서, placeholderData는 Observer Level에서 돌아간다 !

초기설정

먼저 QueryClient를 선언해주어야 한다. Providers폴더에 Query부분에서 queryClient.tsx를 만들었다.

// Provider.tsx

'use client';

import React from 'react';

import { QueryClientProvider, QueryClient } from '@tanstack/react-query';

type Props = {
  children: React.ReactNode;
};

function Providers({ children }: Props) {
  const [client] = React.useState(
    new QueryClient({
      defaultOptions: {
        queries: {
          cacheTime: Infinity,
          staleTime: Infinity,
          suspense: true,
        },
      },
    })
  );

  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

export default Providers;

그리고 이 queryClient를 단 한번만 생성하고, 다시 사용하기 위해 싱글톤 형태로 queryClient를 만들어준다.

왜 싱글톤 형식으로 만들어야만 할까!

사용자별로, 다른 분리된 상태를 유지하고, 요청당 하나의 QueryClient를 유지하기 위 해서이다.

// app/getQueryClient.jsx

import { cache } from 'react';

import { QueryClient } from '@tanstack/react-query';

const getQueryClient = cache(() => new QueryClient({}));
export default getQueryClient;

이렇게 하고 App Router의 layout파일에서 선언한 Providers로 감싸주면 모든 준비가 끝난다.

initalData

tanstack-query는 SSG와 SSR모두에서 pre-rendering을 제공한다.

기본적으로, initalData는 staleTime의 영향을 받는다. 만약 initalData를 넣어두고, staleTime을 0으로 잡아두면 (기본값으로), 마운트 되자마자 리패칭을 한다.

//즉시 초기 데이터가 보여지지만,
// 컴포넌트가 마운트 되면 다시 가져옴

const result = useQuery({
  queryKey: ['todos'],
  queryFn: () => fetch('/todos'),
  initalData: initalTodos,
});

만약 이번에는 staleTime을 1000으로 두면, 1초 동안 이 데이터는 신선한 상태가 되고 , 1초동안은 다시 데이터를 패칭하지 않는다. 만약 1초가 지나면 stale 상태로 간주되 어, 필요 시 다시 패칭된다.

const result = useQuery({
  queryKey: ['todos'],
  queryFn: () => fetch('/todos'),
  initalData: initalTodos,
  staleTime: 1000,
});

app router에서 initalData를 사용하는 방법은 서버 컴포넌트에서 데이터를 프리패칭 하고 클라이언트 컴포넌트에 initalData를 props로 전달하는 것이다.

이 방법은 빠르게 설정할 수 있고, 필요에 따라 initalData의 props Drilling이 발생 할 수 있다.

// app/page.jsx
export default async function Home() {
  const initialData = await getPosts();

  return <Posts posts={initialData} />;
}

// app/posts.jsx
('use client');

import { useQuery } from '@tanstack/react-query';

export function Posts(props) {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: props.posts,
  });
  // ...
}

Hydration,Dehydrate

서버에서 데이터를 호출하고 , 클라이언트에 보낼 때 hydration하는 것이다. 그럼 dehydrate와 hydration을 살펴보자.

dehydrate : 서버에서 클라이언트로 전송할 수 있는 형태로 만들기 위해 사용된다. 서 버에서 데이터를 가져오고 이 데이터를 직렬화 해 클라이언트로 전송한다. 직렬화된 데이터는 DehydratedState로 표현되고 hydrate함수를 통해 다시 변환된다.

요기서 직렬화 할 수 없는 것들(함수 등)이 포함되면 serialization에러가 발생한다.

hydrate : 클라이언트에서 직렬화된 상태를 받아서, 서버에서 미리 받은 데이터를 클 라이언트의 쿼리 캐시에 적용해준다.

//query-core/hydration.ts

export function hydrate(
  client: QueryClient,
  dehydratedState: unknown,
  options?: HydrateOptions,
): void {
  //직렬화된 상태 검사 -> 객체인지 아닌지의 검사
  if (typeof dehydratedState !== 'object' || dehydratedState === null) {
    return
  }

  const mutationCache = client.getMutationCache()
  //쿼리 캐시를 가져온다.

  const queryCache = client.getQueryCache()

  //데이터를 역직렬화해주는 함수
  //서버 -> 클라로의 직렬화
  //클라에서의 역직렬화

  //queryClient으로부터 가져온다.

  const deserializeData =
    options?.defaultOptions?.deserializeData ??
    client.getDefaultOptions().hydrate?.deserializeData ??
    defaultTransformerFn


  //dehydratedState으로부터 mutation,query들을 모두 추출

  const mutations = (dehydratedState as DehydratedState).mutations || []
  const queries = (dehydratedState as DehydratedState).queries || []

  //state.data를 역직렬화해서 쿼리의 data에 넣어준다.

  queries.forEach(({ queryKey, state, queryHash, meta, promise }) => {
    let query = queryCache.get(queryHash)

    const data =
      state.data === undefined ? state.data : deserializeData(state.data)

    // Do not hydrate if an existing query exists with newer data
    if (query) {
      if (query.state.dataUpdatedAt < state.dataUpdatedAt) {
        // omit fetchStatus from dehydrated state
        // so that query stays in its current fetchStatus
        const { fetchStatus: _ignored, ...serializedState } = state
        query.setState({
          ...serializedState,
          data,
        })
      }
    }
    //..실패했을 떄의 처리
}

사용해볼까?

  • 먼저 queryClient를 만든다.
  • 미리 가져오고 싶은 쿼리에 대해 queryClient.prefetchQuery를 실행한다.
  • 이제 dehydrate(queryClient)를 반환한다.
  • HydrationBoundary state로 트리를 감싸준다.

먼저 queryClient를 만들고 13버전이기 때문에, layout.tsx에서 queryClient를 감싸주 자!

// Provider.tsx

'use client';

import React from 'react';

import { QueryClientProvider, QueryClient } from '@tanstack/react-query';

type Props = {
  children: React.ReactNode;
};

function Providers({ children }: Props) {
  const [client] = React.useState(
    new QueryClient({
      defaultOptions: {
        queries: {
          cacheTime: Infinity,
          staleTime: Infinity,
          suspense: true,
        },
      },
    })
  );

  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

export default Providers;

이제 queryClient를 싱글톤 형식으로 관리하기 위해 getQueryClient라는 함수를 만들 고 queryClient를 한번만 실행되게 만들었다(물론 상황에 따라 queryClient를 여러개 놓고? 관리할 수도 있지 않을까..?)

//getQueryClient
import { cache } from 'react';

import { QueryClient } from '@tanstack/react-query';

const getQueryClient = cache(() => new QueryClient({}));
export default getQueryClient;

이제 저 getQueryClient로 queryClient를 가져오고, prefetchQuery를 통해 데이터를 미리 가져와보자!!

import { dehydrate } from '@tanstack/react-query';

import CategoryList from '@/Component/CategoryList/CategoryList';
import Hydrate from '@/Component/Common/Hydrate';
import PostContainer from '@/Component/Post/PostContainer';
import { postQueryKey } from '@/hooks/queries/queryKey';
import { getPosts } from '@/services/Post';
import getQueryClient from '@/utils/getQueryClient';
import { getAllCategories } from '~/lib/api';

export default async function Home() {
  const queryClient = getQueryClient();

  //서버에서 미리 데이터를 가져오고 이를 queryClient에 전달해두자!
  await queryClient.prefetchInfiniteQuery({
    queryKey: postQueryKey.all,
    queryFn: () => getPosts({ pageParams: 0 }),
  });
  const dehydratePostState = dehydrate(queryClient, {
    shouldDehydrateQuery: () => true,
  });
  const allCategory = getAllCategories();

  return (
    <>
      <CategoryList category={allCategory}></CategoryList>
      <main>
        <Hydrate state={dehydratePostState}>
          <PostContainer />
        </Hydrate>
      </main>
    </>
  );
}

나는 블로그 목록에 대해 prefetchQuery를 적용했다. 그럼 실제로 어떻게 불러오는지 확인해보자!

마크다운 이미지

잘 동작하는 것을 볼 수 있다!

tsconfig path를 린트에 적용하기

모던 리액트 2장

  • initalData_placeholderData
  • cacheLevel_ObserverLevel
  • 초기설정
  • initalData
  • Hydration,Dehydrate
  • 사용해볼까?